Tutoriel REPL

Tutoriel REPL Haskeline

Le code source de cet exemple peut être téléchargé ici.

Introduction

Nous avons réalisé dans le tutoriel "Tutoriel REPL" une boucle REPL très basique. L'interface ne contient pas les fonctionnalités qui permettent de la rendre pratique à utiliser telles que :

  • L'historique des commandes.

  • La complétion automatique.

Pour palier à cela, nous allons voir comment utiliser la bibliothèque Haskeline qui apporte les fonctionnalités qui nous manque.

Haskeline

Haskeline est une bibliothèque Haskell qui apporte les mêmes fonctionnalités que readline (écrite en C). Elle est fiable et facile à utiliser.

On peut créer et utiliser une ligne de commande avec Haskeline de 2 manières:

  1. La première, en lançant une monade propre à cette bibliothèque InputT m a qui gère la ligne de commande et sur laquelle on va lancer les différentes fonctions que l'on souhaite lancer.

  2. La seconde, en appelant la ligne de commande à l'intérieur d'une monade IO aux moments ou on le souhaite.

Cette seconde solution présente l'avantage de pouvoir appeler des fonctions de type IO a sans avoir à passer par la fonction liftIO ce qui simplifie le développement de programme avec beaucoup d'accès au système.

Dans le tutoriel qui va suivre, c'est cette seconde solution que je vais présenter.

Implémentation

Boucle REPL

La boucle REPL reprend globalement la même structure que précédemment avec les mêmes fonctionnalités.

Initialisation de la ligne de commande

Pour commencer, il faut initialiser la ligne de commande avec initializeInput avec les préférences utilisateurs définis dans mySettings.

complete : 

Permet de définir une fonction pour la complétion automatique. Pour l'instant, la complétion est désactivée avec noCompletion nous verrons plus loin comment l'utiliser.

historyFile : 

Un possible fichier d'historique ou les commandes seront enregistrées et récupérées lors d'une autre session.

autoAddHistory : 

Ajoute automatiquement les commandes tappées à l'historique.

La ligne de commande initialisée avec initializeInput doit être passée comme argument dans la boucle REPL afin de pouvoir être utilisée pendant la boucle.

main = do
  inp <- initializeInput mySettings
  putStrLn $ unlines help
  replLoop inp 1


mySettings = Settings {
            complete = noCompletion
          , historyFile = Just "history.hist"
          , autoAddHistory = True
          }

Lecture

C'est lors de la phase de lecture que l'on va prendre et utiliser l'entrée haskeline. On la lance avec queryInput en donnant comme argument la ligne créée et les commandes à lancer.

Ici, on utilise getInputLine en donnant la chaîne de caractère à utiliser comme invite. La fonction lance une ligne de saisie vide ou l'utilisateur tape sa commande qui apparait en clair à l'écran.

Il est à noter que Haskeline apporte d'autres fonctions pour la ligne de saisie comme:

getPassword : 

qui lance une saisie en masquant ce que tape l'utilisateur. Idéal pour un mot de passe comme son nom l'indique.

getInputLineWithInitial : 

qui lance une saisie comme getInputLine mais avec une ligne pré-rempli.

replRead inp i= do
   com <- queryInput inp (getInputLine ("ma commande "++show i++" >"))
   return com

Evaluation

L'évaluation se fait en analysant la chaîne de caractère de la commande et en exécutant les actions correspondantes comme déjà expliqué dans le tutoriel précédent.

La seule différence se situe au niveau de la sortie du programme. En effet, il est impératif de clôturer correctement la ligne de commande avec la fonction closeInput sans quoi, l'historique ne sera pas sauvegardé dans le fichier.

replEval inp com@(':' : 'q' : 'u' : 'i' : 't' : 't' : 'e' : 'r' : _   ) = do
    putStrLn "Au revoir !"
    closeInput inp
    exitSuccess
    return []

Affichage

L'affichage se fait de la même manière que dans le tutoriel précédent

La boucle

La boucle se lance via une fonction qui lance les différentes étapes de la boucle avant de s'appeler récursivement elle-même.

La petite différence vient du fait que la ligne de commande Haskeline retourne le type Maybe String que l'on analyse avec un case.

replLoop inp i = do
    mbcom <- replRead inp i
    case mbcom of
        Nothing  -> return ()
        Just com -> do
            res <- replEval inp com
            replPrint res
            replLoop inp (i + 1)

!!! File REPL2_fr-Historique.gif not found !!!

Complétion automatique

Nous avons maintenant une ligne de commande fonctionnelle, mais il manque encore la complétion automatique. Pour la faire fonctionner, il faut créer une fonction spécifique à passer dans le type Setting lors du lancement de la ligne de commande.

Cette fonction (qui fonctionne au sein d'une monade) a comme signature CompletionFunc m = (String, String) -> m (String, [Completion]) et prends comme argument un couple de chaîne de caractères provenant de la ligne de commande Haskeline et contenant la portion avant le curseur et la portion située après le curseur.

Des fonctions permettant de générer des complétions personnalisées sont disponibles et facilite le travail de développement.

Par exemple completeFilename permet de lister les fichiers du répertoire courant et d'avoir accès à leurs noms en complétion.

On trouve également completeWord qui permet de compléter la commande en cours avec comme arguments:

  • Un possible caractère d'échappement

  • Une liste de caractères considérés comme des espaces

  • Une fonction qui retourne une liste de Completion en fonction du début de la ligne de commande.

C'est cette fonction que nous allons utiliser dans ce tutoriel pour faire ce que l'on veut. D'ailleurs que veut-on ?

  1. Dans un premier temps, on veut que les commandes disponibles puissent être remplies automatiquement

  2. Dans un deuxième temps, que lorsque la commande :info est complètement tapée, la complétion propose la liste des fichiers disponibles dans le répertoire courant.

Complétion des commandes

Pour compléter les commandes, nous allons simplement créer une liste de complétions en fonction de la commande qui a commencé à être tapée.

Pour cela, on crée une liste des commandes disponibles associées à une petite explication sur ce que fait la fonction.

Cette liste est filtrée avec la fonction isPrefixOf pour tester si les commandes de la liste commencent par la chaine de la ligne commande. Le résultat filtré est "mappé" pour créer une liste de complétion contenant la commande complète et l'explication sur cette commande qui sera affichée dans les suggestions.

searchFunc str = return $ map (\(a, b) -> Completion a (a ++ " *" ++ b) False) lstcomp
    where
        lstcomp = filter
            (\(a, b) -> str `isPrefixOf` a)
            [ (":ls"     , "Liste les fichiers du répertoire courant")
            , (":info"   , "Donne des informations sur un fichier")
            , (":heure"  , "Donne l'heure du système")
            , (":date"   , "Donne la date du système")
            , (":quitter", "Quitte le programme")
            ]

Complétion des fichiers avec la commande :info

Pour compléter la commande :info avec un nom de fichiers valide, on commence par reconnaitre la commande avec une empreinte et on lance alors les fonctions nécessaires au listing des noms de fichiers.

On récupère le répertoire courant avec getCurrentDirectory et le contenu de ce répertoire avec getDirectoryContents. On filtre ensuite les résultats afin de supprimer les entrées qui ne commence pas par le nom de fichier déjà renseigné par l'utilisateur et les entrées spéciales . et ...

Le résultat final est "mappé" pour créer une liste de complétion contenant la commande :info suivi par un espace et le nom de fichier possible Les suggestions contiennent uniquement les noms des fichiers.

searchFunc inp@(':' : 'i' : 'n' : 'f' : 'o' : _) = do
    dir     <- getCurrentDirectory
    content <- getDirectoryContents dir
    let filcomp  = dropWhile (== ' ') $ dropWhile (/= ' ') inp
        filtered = filter (\f -> notElem f [".", ".."] && (filcomp `isPrefixOf` f)) content
    return $ map (\a -> Completion (":info " ++ a) a False) filtered

!!! File REPL2_fr-Completion.gif not found !!!

Conclusion

Nous avons maintenant une ligne de commande complète, facilement utilisable et contenant tout ce qu'il faut pour faire de beaux programmes interactifs en ligne de commande.

Évidemment, des modifications seraient à apporter pour permettre au programme de gérer des lignes de commandes plus complexes.

La première modification à apporter serait d'utiliser un type dédié pour décrire l'ensemble des commandes utilisables:

data Commandes =
               | ComInfo
               | ComLs
               | ComHeure
               | ComDate

Une autre modification seraient d'utiliser les bibliothèques parsec ou megaparsec afin d'analyser des lignes de commandes plus complexes